/* Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.flowable.engine.impl.scripting;

import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

import javax.script.AbstractScriptEngine;
import javax.script.Bindings;
import javax.script.Compilable;
import javax.script.CompiledScript;
import javax.script.ScriptContext;
import javax.script.ScriptEngine;
import javax.script.ScriptEngineFactory;
import javax.script.ScriptException;
import javax.script.SimpleBindings;

import org.flowable.common.engine.api.FlowableException;
import org.flowable.common.engine.impl.de.odysseus.el.util.SimpleResolver;
import org.flowable.common.engine.impl.el.DynamicBeanPropertyELResolver;
import org.flowable.common.engine.impl.el.ExpressionFactoryResolver;
import org.flowable.common.engine.impl.el.JsonNodeELResolver;
import org.flowable.common.engine.impl.javax.el.ArrayELResolver;
import org.flowable.common.engine.impl.javax.el.BeanELResolver;
import org.flowable.common.engine.impl.javax.el.CompositeELResolver;
import org.flowable.common.engine.impl.javax.el.CouldNotResolvePropertyELResolver;
import org.flowable.common.engine.impl.javax.el.ELContext;
import org.flowable.common.engine.impl.javax.el.ELException;
import org.flowable.common.engine.impl.javax.el.ELResolver;
import org.flowable.common.engine.impl.javax.el.ExpressionFactory;
import org.flowable.common.engine.impl.javax.el.FunctionMapper;
import org.flowable.common.engine.impl.javax.el.ListELResolver;
import org.flowable.common.engine.impl.javax.el.MapELResolver;
import org.flowable.common.engine.impl.javax.el.ResourceBundleELResolver;
import org.flowable.common.engine.impl.javax.el.ValueExpression;
import org.flowable.common.engine.impl.javax.el.VariableMapper;
import org.flowable.common.engine.impl.util.ReflectUtil;
import org.flowable.engine.impl.bpmn.data.ItemInstance;

/**
 * ScriptEngine that used JUEL for script evaluation and compilation (JSR-223).
 * 
 * Uses EL 1.1 if available, to resolve expressions. Otherwise it reverts to EL 1.0, using {@link ExpressionFactoryResolver}.
 * 
 * @author Frederik Heremans
 */
public class JuelScriptEngine extends AbstractScriptEngine implements Compilable {

    private ScriptEngineFactory scriptEngineFactory;
    private ExpressionFactory expressionFactory;

    public JuelScriptEngine(ScriptEngineFactory scriptEngineFactory) {
        this.scriptEngineFactory = scriptEngineFactory;
        // Resolve the ExpressionFactory
        expressionFactory = ExpressionFactoryResolver.resolveExpressionFactory();
    }

    public JuelScriptEngine() {
        this(null);
    }

    @Override
    public CompiledScript compile(String script) throws ScriptException {
        ValueExpression expr = parse(script, context);
        return new JuelCompiledScript(expr);
    }

    @Override
    public CompiledScript compile(Reader reader) throws ScriptException {
        // Create a String based on the reader and compile it
        return compile(readFully(reader));
    }

    @Override
    public Object eval(String script, ScriptContext scriptContext) throws ScriptException {
        ValueExpression expr = parse(script, scriptContext);
        return evaluateExpression(expr, scriptContext);
    }

    @Override
    public Object eval(Reader reader, ScriptContext scriptContext) throws ScriptException {
        return eval(readFully(reader), scriptContext);
    }

    @Override
    public ScriptEngineFactory getFactory() {
        synchronized (this) {
            if (scriptEngineFactory == null) {
                scriptEngineFactory = new JuelScriptEngineFactory();
            }
        }
        return scriptEngineFactory;
    }

    @Override
    public Bindings createBindings() {
        return new SimpleBindings();
    }

    private Object evaluateExpression(ValueExpression expr, ScriptContext ctx) throws ScriptException {
        try {
            return expr.getValue(createElContext(ctx));
        } catch (ELException elexp) {
            throw new ScriptException(elexp);
        }
    }

    private ELResolver createElResolver() {
        CompositeELResolver compositeResolver = new CompositeELResolver();
        compositeResolver.add(new ArrayELResolver());
        compositeResolver.add(new ListELResolver());
        compositeResolver.add(new MapELResolver());
        compositeResolver.add(new JsonNodeELResolver());
        compositeResolver.add(new ResourceBundleELResolver());
        compositeResolver.add(new DynamicBeanPropertyELResolver(ItemInstance.class, "getFieldValue", "setFieldValue"));
        compositeResolver.add(new BeanELResolver());
        compositeResolver.add(new CouldNotResolvePropertyELResolver());
        return new SimpleResolver(compositeResolver);
    }

    private String readFully(Reader reader) throws ScriptException {
        char[] array = new char[8192];
        StringBuilder strBuffer = new StringBuilder();
        int count;
        try {
            while ((count = reader.read(array, 0, array.length)) > 0) {
                strBuffer.append(array, 0, count);
            }
        } catch (IOException exp) {
            throw new ScriptException(exp);
        }
        return strBuffer.toString();
    }

    private ValueExpression parse(String script, ScriptContext scriptContext) throws ScriptException {
        try {
            return expressionFactory.createValueExpression(createElContext(scriptContext), script, Object.class);
        } catch (ELException ele) {
            throw new ScriptException(ele);
        }
    }

    private ELContext createElContext(final ScriptContext scriptCtx) {
        // Check if the ELContext is already stored on the ScriptContext
        Object existingELCtx = scriptCtx.getAttribute("elcontext");
        if (existingELCtx instanceof ELContext) {
            return (ELContext) existingELCtx;
        }

        scriptCtx.setAttribute("context", scriptCtx, ScriptContext.ENGINE_SCOPE);

        // Built-in function are added to ScriptCtx
        scriptCtx.setAttribute("out:print", getPrintMethod(), ScriptContext.ENGINE_SCOPE);

        SecurityManager securityManager = System.getSecurityManager();
        if (securityManager == null) {
            scriptCtx.setAttribute("lang:import", getImportMethod(), ScriptContext.ENGINE_SCOPE);
        }

        ELContext elContext = new ELContext() {

            ELResolver resolver = createElResolver();
            VariableMapper varMapper = new ScriptContextVariableMapper(scriptCtx);
            FunctionMapper funcMapper = new ScriptContextFunctionMapper(scriptCtx);

            @Override
            public ELResolver getELResolver() {
                return resolver;
            }

            @Override
            public VariableMapper getVariableMapper() {
                return varMapper;
            }

            @Override
            public FunctionMapper getFunctionMapper() {
                return funcMapper;
            }
        };
        // Store the elcontext in the scriptContext to be able to reuse
        scriptCtx.setAttribute("elcontext", elContext, ScriptContext.ENGINE_SCOPE);
        return elContext;
    }

    private static Method getPrintMethod() {
        try {
            return JuelScriptEngine.class.getMethod("print", new Class[] { Object.class });
        } catch (Exception exp) {
            // Will never occur
            return null;
        }
    }

    public static void print(Object object) {
        System.out.print(object);
    }

    private static Method getImportMethod() {
        try {
            return JuelScriptEngine.class.getMethod("importFunctions", new Class[] { ScriptContext.class, String.class, Object.class });
        } catch (Exception exp) {
            // Will never occur
            return null;
        }
    }

    public static void importFunctions(ScriptContext ctx, String namespace, Object obj) {
        Class<?> clazz = null;
        if (obj instanceof Class) {
            clazz = (Class<?>) obj;
        } else if (obj instanceof String) {
            try {
                clazz = ReflectUtil.loadClass((String) obj);
            } catch (FlowableException ae) {
                throw new ELException(ae);
            }
        } else {
            throw new ELException("Class or class name is missing");
        }
        Method[] methods = clazz.getMethods();
        for (Method m : methods) {
            int mod = m.getModifiers();
            if (Modifier.isStatic(mod) && Modifier.isPublic(mod)) {
                String name = namespace + ":" + m.getName();
                ctx.setAttribute(name, m, ScriptContext.ENGINE_SCOPE);
            }
        }
    }

    /**
     * Class representing a compiled script using JUEL.
     * 
     * @author Frederik Heremans
     */
    private class JuelCompiledScript extends CompiledScript {

        private ValueExpression valueExpression;

        JuelCompiledScript(ValueExpression valueExpression) {
            this.valueExpression = valueExpression;
        }

        @Override
        public ScriptEngine getEngine() {
            // Return outer class instance
            return JuelScriptEngine.this;
        }

        @Override
        public Object eval(ScriptContext ctx) throws ScriptException {
            return evaluateExpression(valueExpression, ctx);
        }
    }

    /**
     * ValueMapper that uses the ScriptContext to get variable values or value expressions.
     * 
     * @author Frederik Heremans
     */
    private class ScriptContextVariableMapper extends VariableMapper {

        private ScriptContext scriptContext;

        ScriptContextVariableMapper(ScriptContext scriptCtx) {
            this.scriptContext = scriptCtx;
        }

        @Override
        public ValueExpression resolveVariable(String variableName) {
            int scope = scriptContext.getAttributesScope(variableName);
            if (scope != -1) {
                Object value = scriptContext.getAttribute(variableName, scope);
                if (value instanceof ValueExpression) {
                    // Just return the existing ValueExpression
                    return (ValueExpression) value;
                } else {
                    // Create a new ValueExpression based on the variable value
                    return expressionFactory.createValueExpression(value, Object.class);
                }
            }
            return null;
        }

        @Override
        public ValueExpression setVariable(String name, ValueExpression value) {
            ValueExpression previousValue = resolveVariable(name);
            scriptContext.setAttribute(name, value, ScriptContext.ENGINE_SCOPE);
            return previousValue;
        }
    }

    /**
     * FunctionMapper that uses the ScriptContext to resolve functions in EL.
     * 
     * @author Frederik Heremans
     */
    private static class ScriptContextFunctionMapper extends FunctionMapper {

        private ScriptContext scriptContext;

        ScriptContextFunctionMapper(ScriptContext ctx) {
            this.scriptContext = ctx;
        }

        private String getFullFunctionName(String prefix, String localName) {
            return prefix + ":" + localName;
        }

        @Override
        public Method resolveFunction(String prefix, String localName) {
            String functionName = getFullFunctionName(prefix, localName);
            int scope = scriptContext.getAttributesScope(functionName);
            if (scope != -1) {
                // Methods are added as variables in the ScriptScope
                Object attributeValue = scriptContext.getAttribute(functionName);
                return (attributeValue instanceof Method) ? (Method) attributeValue : null;
            } else {
                return null;
            }
        }
    }

}
